0%

本节阐述在JS执行阶段,浏览器为其维护的调用栈。主要从:什么是调用栈及调用栈在实际开发中的使用、调用栈产生的问题即解决办法两个方面进行详述

什么是调用栈

通过“ECMAScript”中的内容我们已经知道JS代码是先编译再执行,而在编译阶段会生成AOGO。而浏览器会将AOGO压入栈中对他们进行管理,这样的栈就是调用栈。如下图所示:

p1

打开浏览器控制台,并在代码中打上断点,便可以看到整个调用栈。上图中,栈底为anonymous,其就是GO。全局预编译结束后调用fun2,在调用之前预编译fun2并将其AO压入栈;在fun2中调用了fun1,因此fun1预编译后的AO入栈。最终构成了上述调用栈

函数执行完返回后,其AO就会从调用栈中弹出

我们也可在代码中使用console.trace()来输出调用栈,如下:

1
2
3
4
5
6
7
8
9
let a = 100;
function fun1(){
console.trace();
return a + 100;
}
function fun2(){
return fun1() + 100;
}
console.log(fun2());

p2

由上述内容可知,在实际开发中我们可使用调用栈来查看函数间的调用关系,以调试代码

栈溢出

调用栈是有大小的,当入栈的执行上下文超过一定数目,JS引擎就会报错,我们把这种错误叫做栈溢出。如下述递归代码就会产生栈溢出:

1
2
3
4
function fun(){
fun()
}
fun();

p3

本章主要阐述浏览器的渲染模块(进程),即浏览器是如何将CSS、HTML文件转化为漂亮的页面的呢?

p1

本章将按照上图所示流程进行阐述,且每一步按照输入、处理过程、输出进行阐述

构建DOM树

浏览器不能直接理解HTML,所以需要HTML解析器将其转换为浏览器能够理解的DOM树结构

p2

由上图可知,在构建DOM树阶段,输入内容为HTML;经HTML解析器处理;输出为DOM树结构

你可在控制台执行document,便可以看见页面的DOM结构

样式计算

同样的,浏览器不能直接理解CSS,因此要通过样式计算阶段将CSS转化为浏览器能够理解的样式结构。下图展示了此阶段的处理流程

p3

由上图可知,此阶段的输入内容为不同来源(行内样式、外链样式、<style></style>样式、JS代码设置的样式等)的CSS样式

然后这些不同来源的样式会被解析为对应的CSSStyleSheet,这些CSSStyleSheet会组成一个StyleSheetList类数组(如下图所示)。同时会将CSSStyleSheet中的样式值标准化(如下图)

p4

1、在控制台执行document.styleSheets便可看见CSSStyleSheetStyleSheetList;2、所谓标准化就是将某些值转换为计算值,如上述颜色值转换为rgbem转换为pxblod转换为700

再然后,遵照CSS的继承和层叠规则,基于各个CSSStyleSheet计算出DOM树中每个节点的具体样式(或者说计算样式),最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。你可以在控制台的Computed标签下看见元素的ComputedStyle值,如下图所示:

p5

布局阶段

经过前两个步骤,现在我们有了表示HTML结构的DOM树和树中各节点的样式。但依然还不能开始绘制页面。我们还需要在布局阶段计算DOM树中可见元素的几何位置。该阶段的流程如下

p6

首先,遍历DOM树中的所有可见节点,并将它们添加到布局树中。而不可见的节点将会被忽略掉(如display:none的节点、head节点等)

p7

然后计算布局树中各节点的几何位置,并将位置信息挂载到布局树中的节点上

分层

为了更方便的实现3D变换、页面滚动、z-indexing 做 z 轴排序等复杂效果,渲染引擎还需要为布局树中的某些节点生成专用的图层,并生成一颗对应的图层树(页面正是由这些图层叠加而成的)

那么那些节点会生成图层呢?

1、拥有层叠上下文属性的元素会生成图层。具体有哪些属性可见 层叠上下文

2、需要剪裁的地方也会被创建为图层

div 的大小限定为 200 * 200 像素,并设置了overflow,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,就会为div创建图层

那些没有图层的节点怎么办呢?如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如下图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层

p8

因此此阶段的输入是布局树,输出是图层树

你可以在浏览器的Layers选项卡下看见页面图层,如下:

p9

绘制

接下来会为图层树中的各图层生成对应的待绘制列表。如下图所示:

渲染引擎会把一个图层的绘制拆分为很多小的步骤(就跟我们画画时先画…,再画….,…是一致的),再将这些步骤按顺序组合起来就形成了待绘制列表

p10

你可以在浏览器的Layers选项卡下看见图层的待绘制列表,如下:

p11

分块

当图层的待绘制列表准备好之后,渲染进程的主线程会把该绘制列表提交(commit)给合成线程,如下图所示:

p12

由于用户只能看见整个页面的视口部分,视口之外的页面是看不见的。因此没必要一次性绘制所有图层,否则会耗性能

因此,合成线程会先将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示;

p13

而视口附近的图块会被优先生成位图(见下一步)

光栅化和合成

有了图块,接下来就是将图块进行光栅化(光栅化是指将图块生成位图)

渲染进程维护了一个光栅化的线程池,所有的图块光栅化都是在线程池内执行的。光栅化线程池中的光栅化线程实际上会使用GPU来光栅化————即使用GPU生成位图,再将生成好的位图放入显卡缓冲区,浏览器便从缓冲区获取位图进行展示,这样速度更快。运行方式如下图所示

p14

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上

p15

上图就是浏览器页面渲染的整个流程

三个相关的重要概念

重排(回流)

如下操作会引起重排:

  • 一个 DOM 元素的几何属性变化,常见的几何属性有width、height、padding、margin、left、top、border 等等, 这个很好理解

  • 使 DOM 节点发生增减或者移动

  • 用JS读写 offset 族、scroll 族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作

  • 调用 window.getComputedStyle 方法

为什么重排性能很差?,如下图

p16

由上图可知,由于重排导致了节点几何位置等的变化,那么会从样式计算开始走完后面的整个页面渲染流程,同时还占据了渲染进程的主线程

重绘

当你更改了节点样式的时候会引发重绘。如下图所示,重绘时布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些

p17

合成

那如果你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:

p18

在上图中,我们使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率

本章先总结一下进程和线程的关系、然后讨论以前的单进程浏览器的缺陷、最后叙述现代Chrome浏览器的多进程架构

进程和线程的关系

  • 进程中的任意一个线程执行出错,都会导致整个进程的崩溃
  • 同一进程中的线程共享该进程中的数据
  • 当一个进程关闭之后,操作系统会回收进程所占用的内存
  • 各个进程之间相互隔离。因此一个进程崩溃不会影响其他进程

单进程浏览器

早期的<=IE6浏览器就是单进程浏览器

单进程浏览器是指:整个浏览器只有一个进程,它的所有功能模块(如网络、插件、渲染引擎等)都是以线程的方式运行于一个进程中。如下图:

p1

如此多的功能模块运行于一个进程之中会导致如下问题:

  • 浏览器不稳定

因为前面说到“进程中的任意一个线程执行出错,都会导致整个进程的崩溃”,因此如果这些以线程方式运行的功能模块中的某个出错势必会导致整个浏览器进程的崩溃。如早期浏览器中的视频播放需要插件支持,而插件十分容易出问题

  • 浏览器卡顿

由上图可知,所有页面的页面渲染、JS执行和插件都是运行在页面线程中的。而一个线程同一时刻只能干一件事情,那么如果某个页面中的JS中有一个死循环的话就会导致其他页面的渲染、JS执行和插件不能执行,从而导致其他页面和整个浏览器无响应

  • 不安全

虽然我们可以为进程建立安全沙箱(你可以把沙箱看作是操作系统给进程上了一把锁,沙箱中的程序不能操作系统中的数据),但是单进程浏览器只有一个进程,而有时我们譬如要上传文件,这就意味着我们不能给这个进程建立沙箱。如此一来,脚本或插件等就有可能操作系统数据

多进程浏览器

因为单进程浏览器的上述问题,便出现了多进程架构的浏览器。以Chrome的多进程架构为例

p2

上图是Chrome的多进程架构。从图中可以看出其包括一个浏览器主进程、一个GPU进程、一个网络进程、多个渲染进程、多个插件进程(还有其他很多进程,这儿仅阐述这五个主要的进程)。下面分别讨论上述进程

  • 浏览器进程 主要负责界面显示(见“第5章”)、用户交互、子进程管理、同时提供存储等功能
  • 渲染进程 核心任务是将HTML、CSS和JS转换为用户可以与之交互的网页。排版引擎Blink和JS引擎V8都是运行在该进程中。Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑(JS脚本威胁),渲染引擎运行于沙箱中
  • GPU进程 GPU的初衷是为了实现3D CSS效果,后来网页也选用GPU绘制(见“第5章”),这使得GPU成为浏览器普遍的需求
  • 网络进程 负责页面的网络资源加载
  • 插件进程 负责插件的运行,因插件易崩溃,所以需要插件进程来隔离,以保证插件的崩溃不会导致页面的崩溃。Chrome会为每个Tab标签创建一个插件进程

一个网页至少应该有四个进程——浏览器进程、渲染进程、GPU进程、网络进程。如果网页还运行了插件则还有插件进程

那么多进程浏览器又是如何解决单进程浏览器的问题的呢?

  • 浏览器不稳定问题。由于多进程浏览器中的各线程都有自己的进程,而不再像单线程浏览器那样属于一个浏览器进程。因此即使线程执行出错也仅导致线程所在的那个进程崩溃,而不会使整个浏览器进程崩溃
  • 浏览器卡顿问题。由于每个页面都有一个渲染进程,因此即使JS代码出现了死循环,也仅仅影响的是某一个页面
  • 不安全问题。由上图可知,渲染进程、插件进程都是在沙箱中运行

虽然多进程架构很好!但也伴随有如下问题:

  • 更高的资源占用。譬如每个页面都会有一个渲染进程
  • 更复杂的体系架构

本篇文章将阐述从HTTP/0.9到HTTP/3的发展过程

HTTP/0.9

HTTP/0.9主要用于学术交流,仅用来传递HTML文件。因此其具有如下特点:

  • 客户端仅通过一个请求行(如GET /index.html)便可告诉服务器想要的HTML文件,因此无请求头/体
  • 服务器仅返回HTML文件即可,因此无响应头/体
  • HTML文件均采用ASCLL编码,无其它格式

HTTP/1.0

在HTTP/1.0时代,网络文件不再局限于HTML。还包括CSS、JS、图片、音频等文件。因此支持多种类型的文件下载是 HTTP/1.0 的一个核心诉求

为了实现多类型文件的传输,需要解决如下问题:

  • 浏览器需要知道服务器返回的数据是什么类型的,然后浏览器才能根据不同的数据类型做针对性的处理
  • 由于万维网所支持的应用变得越来越广,所以单个文件的数据量也变得越来越大。为了减轻传输性能,服务器会对数据进行压缩后再传输,所以浏览器需要知道服务器压缩的方法以便解压收到的数据
  • 由于万维网是支持全球范围的,因此服务器需要对不同的地区提供不同的语言版本,这就需要浏览器告诉服务器它想要什么语言版本的页面
  • 由于增加了各种不同类型的文件,而每种文件的编码形式又可能不一样,为了能够准确地读取文件,浏览器需要知道文件的编码类型

为了解决上述问题,HTTP/1.0引入了请求/响应头

1
2
3
4
5
6
7
8
9
/*请求头*/
accept: text/html
accept-encoding: gzip, deflate, br
accept-Charset: ISO-8859-1,utf-8
accept-language: zh-CN,zh

/*响应头*/
content-encoding: br
content-type: text/html; charset=UTF-8
  • accept表示希望服务器返回的文件类型
  • accept-encoding表示希望服务器采用的压缩方式
  • accept-Charset表示希望服务器采用的文件编码
  • accept-language表示希望服务器返回的页面语言
  • content-encoding表示服务器采用的压缩方式
  • content-type表示服务器返回的文件类型即文件采用的编码

除此之外,HTTP/1.0还引入了缓存机制和状态码

HTTP/1.1

HTTP/1.1做了如下改进

  • 持久连接(每个域名最多同时维护 6 个 TCP 持久连接)。HTTP/1.0 每进行一次 HTTP 通信,都需要经历建立 TCP 连接。但是随着单个页面所依赖的外部文件越来越多,如果在下载每个文件的时候,都需要经历建立 TCP 连接、传输数据和断开连接这样的步骤,无疑会增加大量无谓的开销。据此,HTTP/1.1 中增加了持久连接的方法,它的特点是在一个 TCP 连接上可以传输多个 HTTP 请求,只要浏览器或者服务器没有明确断开连接,那么该 TCP 连接会一直保持
  • 引入了cookie、安全机制(见“安全”一章)

但HTTP/1.1也有如下问题:

  • 由于TCP拥塞控制慢启动的原因,最开始数据的发送速率较低,因此会导致资源加载时间变长
  • 同时开启的多条 TCP 连接会竞争固定的带宽。当发现带宽不足的时候,各个 TCP 连接就需要动态减慢接收数据的速度
  • 持久连接虽然能减少 TCP 的建立和断开次数,但是它需要等待前面的请求返回之后,才能进行下一次请求。如果 TCP 通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是著名的队头阻塞的问题

HTTP/2.0

为解决HTTP/1.1中的问题,HTTP/2.0采用了多路复用技术。具体如下:

  • 一个域名只使用一个 TCP 长连接来传输数据,这样整个页面资源的下载过程只需要一次慢启动,同时也避免了多个 TCP 连接竞争带宽所带来的问题
  • 另外,就是队头阻塞的问题。HTTP/2 实现资源的并行请求,也就是任何时候都可以将请求发送给服务器,而并不需要等待其他请求的完成,然后服务器也可以随时返回处理好的请求资源给浏览器

多路复用的实现方法如下:

p1

从图中可以看出,HTTP/2 添加了一个二进制分帧层,那我们就结合图来分析下 HTTP/2 的请求和接收过程

  • 首先,浏览器准备好请求数据,包括了请求行、请求头等信息,如果是 POST 方法,那么还要有请求体
  • 这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求 ID 编号的帧,通过协议栈将这些帧发送给服务器
  • 服务器接收到所有帧之后,会将所有相同 ID 的帧合并为一条完整的请求信息
  • 然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层
  • 同样,二进制分帧层会将这些响应数据转换为一个个带有请求 ID 编号的帧,经过协议栈发送给浏览器
  • 浏览器接收到响应帧之后,会根据 ID 编号将帧的数据提交给对应的请求

除此之外,HTTP/2.0还有如下特性

  • 服务器推送。你可以想象这样一个场景,当用户请求一个 HTML 页面之后,服务器知道该 HTML 页面会引用几个重要的 JavaScript 文件和 CSS 文件,那么在接收到 HTML 请求之后,附带将要使用的 CSS 文件和 JavaScript 文件一并发送给浏览器,这样当浏览器解析完 HTML 文件之后,就能直接拿到需要的 CSS 文件和 JavaScript 文件,这对首次打开页面的速度起到了至关重要的作用
  • 头部压缩。HTTP/2 对请求头和响应头进行了压缩,你可能觉得一个 HTTP 的头文件没有多大,压不压缩可能关系不大,但你这样想一下,在浏览器发送请求的时候,基本上都是发送 HTTP 请求头,很少有请求体的发送,通常情况下页面也有 100 个左右的资源,如果将这 100 个请求头的数据压缩为原来的 20%,那么传输效率肯定能得到大幅提升

(15) generator生成器

generator的基本使用

我们可通过function*来定义一个generator函数。调用 generator 函数,返回一个遍历器对象。以后,每次调用遍历器对象的next方法,就会返回一个有着valuedone两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束

遍历器对象中的next方法运行逻辑如下:

  1. 遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值
  2. 下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式
  3. 如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值
  4. 如果该函数没有return语句,则返回的对象的value属性值为undefined
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function* test(){
yield 1;
yield 2;
yield 3;
return 4;
}

let gen = test();
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());
console.log(gen.next());

// { value: 1, done: false }
// { value: 2, done: false }
// { value: 3, done: false }
// { value: 4, done: true }
// { value: undefined, done: true }

next方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。由于next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。V8 引擎直接忽略第一次使用next方法时的参数,只有从第二次使用next方法开始,参数才是有效的。从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function* test(){
let v1 = yield 1;//22作为yield表达式——“yield 1”——的返回值,赋给v1
console.log(v1);//22
let v2 = yield 2;//33作为yield表达式——“yield 2”——的返回值,赋给v2
console.log(v2);//33
let v3 = yield 3;//44作为yield表达式——“yield 3”——的返回值,赋给v3
console.log(v3);//44
return 4;
}

let gen = test();
console.log(gen.next(11));
console.log(gen.next(22));
console.log(gen.next(33));
console.log(gen.next(44));
console.log(gen.next(55));

// { value: 1, done: false }
//22
// { value: 2, done: false }
//33
// { value: 3, done: false }
//44
// { value: 4, done: true }
// { value: undefined, done: true }

for…of…遍历

for...of循环可以自动遍历 generator 函数调用时生成的遍历器对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* test(){
let v1 = yield 1;
let v2 = yield 2;
let v3 = yield 3;
return 4;
}

let gen = test();

for(let k of gen){
console.log(k);
}
//1
//2
//3

这里需要注意,一旦next方法的返回对象的done属性为truefor...of循环就会中止,且不包含该返回对象,所以上面代码的return语句返回的4,不包括在for...of循环之中

遍历器对象中的三个方法

Generator.prototype.throw()

该方法用于在generator函数体外抛出错误,然后在generator函数体内捕获错误

上面我们说过,可以通过带参数的next方法为generator函数内部传入参数,以作为yield表达式的返回值。这儿的throw方法实际上可以理解为.next(new Error(...)),即通过nextgenerator函数内部传入了一个错误,以作为yield表达式的返回值

同时,我们也可以为throw设置参数,该参数即为错误信息

1
2
3
4
5
6
7
8
9
10
11
function* test(){
try{
let v1 = yield 1;//调用throw后,该行抛出错误
let v2 = yield 2;
}catch(e){
console.log(e);//出错了
}
}
let gen = test();
console.log(gen.next());//{ value: 1, done: false }
gen.throw('出错了');//相当于gen.next(new Error('出错了'));

通过throw抛出错误后,应该在generator函数体内的相应yield语句处用try...catch...捕获错误;如果没在generator函数中捕获错误,那么抛出的错误就会掉到调用throw方法处,你应该在该处捕获错误;如果在generator函数中和throw处都未捕获错误,那么整个脚本将终止执行

1
2
3
4
5
6
7
8
9
10
function* test(){
let v1 = yield 1;
}
let gen = test();
console.log(gen.next());//{ value: 1, done: false }
try{
gen.throw('出错了');//相当于gen.next(new Error('出错了'));
}catch(e){
console.log(e);//出错了
}

需要注意的是:

1、throw方法抛出的错误要被内部捕获,前提是必须至少执行过一次next方法

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
try {
yield 1;
} catch (e) {
console.log('内部捕获');
}
}

var g = gen();
g.throw(1);
// Uncaught 1

上面代码中,g.throw(1)执行时,next方法一次都没有执行过。这时,抛出的错误不会被内部捕获,而是直接在外部抛出,导致程序出错。这种行为其实很好理解,因为第一次执行next方法,等同于启动执行 generator 函数的内部代码,否则 generator 函数还没有开始执行,这时throw方法抛错只可能抛出在函数外部

2、throw方法被捕获以后,会附带执行下一条yield表达式。也就是说,会附带执行一次next方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var gen = function* gen(){
try {
yield console.log('a');
} catch (e) {
// ...
}
yield console.log('b');
yield console.log('c');
}

var g = gen();
g.next() // a
g.throw() // b
g.next() // c

3、generator 函数体内抛出的错误,也可以被函数体外的catch捕获

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* foo() {
var x = yield 3;
var y = x.toUpperCase();
yield y;
}

var it = foo();

it.next(); // { value:3, done:false }

try {
it.next(42);
} catch (err) {
console.log(err);
}

上面代码中,第二个next方法向函数体内传入一个参数 42,数值是没有toUpperCase方法的,所以会抛出一个 TypeError 错误,被函数体外的catch捕获

一旦 generator 执行过程中抛出错误,且没有被内部捕获,就不会再执行下去了。如果此后还调用next方法,将返回一个value属性等于undefineddone属性等于true的对象,即 JavaScript 引擎认为这个 generator 已经运行结束了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function* g() {
yield 1;
console.log('throwing an exception');
throw new Error('generator broke!');
yield 2;
yield 3;
}

function log(generator) {
var v;
console.log('starting generator');
try {
v = generator.next();
console.log('第一次运行next方法', v);
} catch (err) {
console.log('捕捉错误', v);
}
try {
v = generator.next();
console.log('第二次运行next方法', v);
} catch (err) {
console.log('捕捉错误', v);
}
try {
v = generator.next();
console.log('第三次运行next方法', v);
} catch (err) {
console.log('捕捉错误', v);
}
console.log('caller done');
}

log(g());
// starting generator
// 第一次运行next方法 { value: 1, done: false }
// throwing an exception
// 捕捉错误 { value: 1, done: false }
// 第三次运行next方法 { value: undefined, done: true }
// caller done

Generator.prototype.return()

generator 函数返回的遍历器对象,还有一个return()方法,可以返回给定的值,并且终结遍历 generator 函数

1
2
3
4
5
6
7
8
9
10
11
function* gen() {
yield 1;
yield 2;
yield 3;
}

var g = gen();

g.next() // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next() // { value: undefined, done: true }

上面代码中,遍历器对象g调用return()方法后,返回值的value属性就是return()方法的参数foo。并且,generator 函数的遍历就终止了,返回值的done属性为true,以后再调用next()方法,done属性总是返回true

如果return()方法调用时,不提供参数,则返回值的value属性为undefined

1
2
3
4
5
6
7
8
9
10
function* gen() {
yield 1;
yield 2;
yield 3;
}

var g = gen();

g.next() // { value: 1, done: false }
g.return() // { value: undefined, done: true }

如果 generator 函数内部有try...finally代码块,且正在执行try代码块,那么return()方法会导致立刻进入finally代码块,执行完以后,整个函数才会结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* numbers () {
yield 1;
try {
yield 2;
yield 3;
} finally {
yield 4;
yield 5;
}
yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }

上面代码中,调用return()方法后,就开始执行finally代码块,不执行try里面剩下的代码了,然后等到finally代码块执行完,再返回return()方法指定的返回值

(14) async与await

asyncawait是基于promise的,他们出现的目的就是为了简化promise的链式调用,使异步编程更加简洁和更具语义化

什么是async与await

async被放在一个函数前面修饰函数。在函数前面加上async表达了两件事:一是,调用async函数会立即返回一个Promise对象;二是,可以在该函数中使用await

什么是await呢?await后面应该是一个执行异步任务的Promise对象,当async函数中的代码在执行过程中遇见await时便会停在await的位置暂停向下执行,直到Promise对象的状态settled下来,然后再向下执行或结束返回(后面会说到)

下面看一下它们在代码中使用是什么样子(看不太懂也没事,后面还会继续讲解)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function test(){
let result1 = await new Promise((resolve, reject) => {
resolve('1');
});
console.log(result1);//1
let result2 = await new Promise((resolve, reject) => {
resolve('2');
});
console.log(result2);//2
return result1 + result2;
}
test().then((value) => {
console.log(value);//12
});

下面对await和其他核心内容做一个详细介绍。(你只需要明白async就是修饰函数的,表示这是一个执行异步任务的函数。因此不再赘述)

详细介绍

await

await后面本应是Promise对象,但也可以是thenable对象(即定义了then方法的对象)、除Promise对象与thenable对象外的其他任何值(包括原始值和引用值)

后面的值不同,await的行为便有所不同。下面分别阐述在不同值下面的await行为:

1、await后面是Promise对象

如果Promise的状态为resolved,则await直接返回resolve函数传递出来的值。如果Promise的状态为rejected,则await不会返回值,而是直接抛出一个错误,错误信息就是reject函数传递出来的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function test(){
let result1 = await new Promise((resolve, reject) => {
resolve('1');
});
console.log(result1);//1 await直接返回`resolve`函数传递出来的值
let result2 = await new Promise((resolve, reject) => {
reject('2');
//等同于
// throw new Error('2');
});
}
test().catch((reason) => {
console.log('出错了:' + reason);//出错了:2
});

2、await后面是除Promise对象与thenable对象外的其他任何值(包括原始值和引用值)

此时await不做任何处理,直接返回后面的值

1
2
3
4
5
6
7
async function test(){
let result1 = await 123;
console.log(result1);//123
let result2 = await [1, 2, 3];
console.log(result2);//[ 1, 2, 3 ]
}
test();

3、await后面是thenable对象

如果 await 接收了一个非 promise 的但是提供了 .then 方法的对象,它就会调用这个 .then 方法,并将内建的函数 resolvereject 作为参数传入(这就是在创建Promise实例时的resolvereject函数)。然后 await 等待直到这两个函数中的某个被调用。如果调用的是resolve,则await直接返回resolve函数传递出来的值;如果调用的是reject,则await不会返回值,而是直接抛出一个错误,错误信息就是reject函数传递出来的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Thenable {
constructor(num) {
this.num = num;
}
then(resolve, reject) {
setTimeout(() => reject(this.num * 2), 1000); // (*)
}
}
async function f() {
try{
let result = await new Thenable(1);
//等同于
// throw new Error(2);
}catch(err){
console.log(err);//2
}
}
f();

async函数中的return

前面说了“调用async函数会立即返回一个Promise对象”,这与return无关(即Promise对象不是用书写return返回的,与return无关)

1
2
async function f() {}
console.log(f());//Promise { undefined }

可见,只要调用async函数就会返回Promise对象,

return通常用于返回async函数中异步操作返回的结果,return返回的值会被传给thenasync函数会返回一个Promise对象,所以可以调用该对象的then方法)中的resolveCallback回调函数

1
2
3
4
5
6
7
8
9
async function f() {
let result = await new Promise((resolve, reject) => {
resolve(1);
});
return result;
}
f().then((value) => {
console.log(value);//1
});

async函数返回的Promise对象的状态

async函数返回的 Promise 对象,必须等到内部所有await命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return语句或者抛出错误(下面在“async”函数的执行流程中会详细说)。且只有当所有await命令后面的 Promise 对象都为resolved状态,那么async函数返回的 Promise 对象才为resolved状态;只要有一个是rejected状态,则async函数返回的 Promise 对象就为reject状态

同时,async函数内部抛出错误(见下面“async函数的执行流程 - 3”),会导致返回的 Promise 对象变为rejected状态

async函数的执行流程

从整体看,async函数的执行流程为:调用async函数开始执行 ——> 遇见await则暂停执行等待异步操作完成再向下执行

但在执行过程中遇到下述两种情况时,async函数会立即中断整个async函数的执行并跳出

1、遇见return语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function f() {
return null;
let promise = await new Promise((resolve, reject) => {
resolve(22);
});
}

f().then((value) => {
console.log(value);//null
});

/******/

async function f() {
let promise1 = await new Promise((resolve, reject) => {
resolve(22);
});
return 123;
let promise2 = await new Promise((resolve, reject) => {
resolve(33);
});
}

f().then((value) => {
console.log(value);//123
});

2、await后面的Promise对象为rejected状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function f() {
let promise1 = await new Promise((resolve, reject) => {
reject(22);
});
console.log('继续需执行?');//不会执行
let promise2 = await new Promise((resolve, reject) => {
resolve(33);
});
}

f().then((value) => {
console.log(value);
}).catch((reason) => {
console.log(reason);//22
});

3、async函数内部抛出错误(不是在里面的Promise中抛出错误)

1
2
3
4
5
6
7
8
9
10
11
12
13
async function f() {
throw new Error(123);
console.log('继续需执行?');//不会执行
let promise1 = await new Promise((resolve, reject) => {
reject(22);
});
}

f().then((value) => {
console.log(value);
}).catch((reason) => {
console.log('出错了' + reason);//出错了Error: 123
});

其他要注意的内容

1、前面我们知道,任何一个await语句后面的 Promise 对象变为rejected状态,那么整个async函数都会中断执行。有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将那个await放在try...catch结构里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function f() {
try{
let result1 = await new Promise((resolve, reject) => {
reject(22);
});
}catch(err){
console.log(err);//22
}
console.log('继续需执行');//继续需执行
let result2 = await new Promise((resolve, reject) => {
resolve(33);
});
return result2;
}

f().then((value) => {
console.log(value);//33
}).catch((reason) => {
console.log(reason);
});

为什么能用try...catch...捕获呢?因为,如果 Promiserejectedawaitthrow 这个 error,就像在这一行有一个 throw 语句那样:

1
2
3
4
5
6
7
8
9
async function f() {
await Promise.reject(new Error("Whoops!"));
}

//等同于

async function f() {
throw new Error("Whoops!");
}

2、async 函数有多种使用形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 函数声明
async function foo() {}

// 函数表达式
const foo = async function () {};

// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)

// Class 的方法
class Storage {
constructor() {
this.cachePromise = caches.open('avatars');
}

async getAvatar(name) {
const cache = await this.cachePromise;
return cache.match(`/avatars/${name}.jpg`);
}
}

const storage = new Storage();
storage.getAvatar('jake').then(…);

// 箭头函数
const foo = async () => {};

基本上,函数出现的位置都能用

3、await只能用在async函数中

4、对于async函数而言,如果其显示的return一个promise,那么async函数返回的promise就是显示的return的那个promise。如:

1
2
3
4
5
6
async (username) => {
let user = await userCol.findOne({ username }).exec();
if(user){
return Promise.reject('用户名已存在');
}
}

上述如果用户名已存在,则async函数返回的promise就是Promise.reject('用户名已存在'),即一个失败状态的promise

5、在实战中我们需要处理await后面的异步操作失败的情况(你却不知如何处理!!!)。在上面 其他要注意的内容一节中的1小点和 详细介绍下的 await一节中的1小点已经说得很明白了,直接用try...catch...处理就行了

实例演示

下面我们将Promise一章中读取文件的例子改用使用async与await:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Promise写法
const p = new Promise((resolve, reject) => {
fs.readFile('./resources/为学.md', (err, data) => {
resolve(data);//读取成功后将结果传给then中注册的resolveCallback回调函数
});
});

p.then((value) => {
return new Promise((resolve, reject) => {
fs.readFile('./resources/插秧诗.md', (err, data) => {
resolve([data, value]);//读取成功后将结果传给then中注册的resolveCallback回调函数
});
});
}).then((value) => {
return new Promise((resolve, reject) => {
fs.readFile('./resources/观书有感.md', (err, data) => {
resolve(value.push(data));//读取成功后将结果传给then中注册的resolveCallback回调函数
});
});
}).then((value) => {
console.log(value[0] + value[1] + value[2]);
});

//async与await写法
async function getFile(){
let text1 = await new Promise((resolve, reject) => {
fs.readFile('./resources/为学.md', (err, data) => {
resolve(data);//读取成功后,结果由await返回给text1
});
});
let text2 = await new Promise((resolve, reject) => {
fs.readFile('./resources/插秧诗.md', (err, data) => {
resolve(data);//读取成功后,结果由await返回给text2
});
});
let text3 = await new Promise((resolve, reject) => {
fs.readFile('./resources/观书有感.md', (err, data) => {
resolve(data);//读取成功后,结果由await返回给text3
});
});
return [text1, text2, text3];//将结果给then中的value
}
getFile().then((value) => {
console.log(value[0] + value[1] + value[2]);
});

(13) 模块

在浏览器中加载模块

在浏览器中加载模块有如下两种方式:

1
2
3
4
5
6
7
<script type="module" src="......"></script>



<script type="module">
//通过import引入模块
</script>

对于第一种方式,浏览器会异步加载模块,并等到整个页面渲染完成再执行模块

模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。下面分别阐述它们

export导出

export可用于导出变量、函数和类

export有三种导出方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*在声明时导出*/
export let name = '德洛丽丝';
export function getName(){
console.log('伯纳德');
}
export class A{}

/*导出与声明分开(集中导出)*/
let name = '德洛丽丝';
function getName(){
console.log('伯纳德');
}
class A{}
export {//实际上是导出后面的对象
name,
getName,
A
}

/*export as*/
let name = '德洛丽丝';
function getName(){
console.log('伯纳德');
}
class A{}
export {
name as index_name,
getName as index_getName,
A as index_A
};

export as能够让导出不同的名字,如上述name将以别名index_name导出。export as只能用于导出与声明分开(集中导出)的方式

import引入

引入也有三种方式:

1
2
3
4
5
6
7
8
9
/*按需引入(即只引入需要的变量/函数/类) import {...} from URL*/
import {getName} from 'URL';//只引入getName函数

/*全部引入(即将导出的所有内容都引入)import * as <obj> from URL*/
import * as indexJS from URL;
indexJS.getName();

/*import as*/
import {getName as index_getName} from URL;

全部引入会将所有导出的内容放于<obj>对象中。import asexport as作用一样,用于起一个新名字

除了上述import的基本用法外,我们还可以看见如下写法

1
2
import { Button, Input } from 'antd';//<1>
import './App.css'//<2>

第<1>种写法from后面不是一个路径,而是一个模块名,这种只写模块名的写法要求通过配置文件告诉引擎在哪儿找这个模块

import语句会执行所加载的模块,因此可以有第<2>种写法

(12) class

定义一个类

ES6中通过如下方式定义一个类:

1
2
3
4
5
6
class 类名{
constructor(参数列表){
this.. = ...;
}
//类的属性与方法
}

然后通过new 类名()的方式创建这个类的实例。其中constructor()方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor()方法,如果没有显式定义,一个空的constructor()方法会被默认添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/***ES5写法***/
function Point(x, y){
this.x = x;
this.y = y;
}

/***class写法***/
class Point{
constructor(x, y){
this.x = x;
this.y = y;
}
}
let point = new Point(1, 2);
console.log(point);//Point { x: 1, y: 2 }

类也有原型,类的原型就是类名后面的{...},也就是说上面的constructor//类的属性与方法实际上都是定义在类的原型上,然后类的所有实例共享该原型,即实例的原型就是类的原型(即都拥有这些内容,这与ES5一样)。可通过类名.prototype获取类的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/***ES5***/
function Point(x, y){
this.x = x;
this.y = y;
}
console.log(Point.prototype.constructor === Point);//true

/***ES6***/
class Point{
constructor(x, y){
this.x = x;
this.y = y;
}
}
console.log(Point.prototype.constructor === Point);//true
console.log(point.__proto__ === Point.prototype);//true

可见,类名实际上就指向了该类的构造函数

定义类的属性与方法时,不需要加逗号分隔,不需要写成键值对形式。同时,我们在定义类的属性时,一般将属性写在类的最前面,以便清晰地展现该类有哪些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point{
_type = 'default';//定义一个属性
constructor(x, y){
this.x = x;
this.y = y;
}
test(){//定义一个方法
console.log('test');
}
}
let point = new Point();
console.log(point._type);//default
point.test();//test

我们可通过[]动态设置类的属性与方法名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let str1 = '_type';
let str2 = 'test';
class Point{
[str1] = 'default';
constructor(x, y){
this.x = x;
this.y = y;
}
[str2](){
console.log('test');
}
}
let point = new Point();
console.log(point._type);//default
point.test();//test

与函数一样,类也可以使用表达式定义

1
2
3
4
5
6
7
const MyClass = class Me {
//类的属性与方法
}



const MyClass = class { //类的属性与方法 }

上述第一种方式中的Me只能在class内部使用(即类名后的{}中使用)指代类本身。在 class 外部,这个类只能用MyClass引用

采用 class 表达式,可以写出立即执行的 class

1
2
3
4
5
6
7
8
9
10
11
let person = new class {
constructor(name) {
this.name = name;
}

sayName() {
console.log(this.name);
}
}('张三');

person.sayName(); // "张三"

上面代码中,person是一个立即执行的类的实例

上面我们说“//类的属性与方法实际上都是定义在类的原型上”。但这种说法是错的,在此做一个订正:

类的方法确实是定义在类的原型上的。但是类的属性(如前面代码中的_type)不是定义在类的原型上的,而是定义在实例上的。这只是“定义实例属性的一种新写法”。这种新写法详述如下:

实例属性除了定义在constructor()方法里面的this上面,也可以定义在constructor之外的类的最顶层(这时,不需要在实例属性前面加上this)。这种新写法的好处是,所有实例对象自身的属性都定义在类的头部,看上去比较整齐,一眼就能看出这个类有哪些实例属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// class A {
// constructor(){
// this.x = 2;
// }
// }

/***新写法***/

class A {
x = 2;
constructor(){
// this.x = 2;
}
}
let a = new A();
console.log(a.x);//2
console.log(A.prototype.x);//undefined

可见类属性并未定义在类的原型上

静态方法

前面说了——类的原型就是实例的原型,因此类中的所有方法为各实例共享。但如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”

1
2
3
4
5
6
7
8
9
10
11
class Foo {
static classMethod() {
return 'hello';
}
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

父类的静态方法可以被子类继承

1
2
3
4
5
6
7
8
9
10
class Foo {
static classMethod() {
return 'hello';
}
}

class Bar extends Foo {
}

Bar.classMethod() // 'hello'

静态方法中的this关键字指的是类本身,而不是实例

1
2
3
4
5
6
7
8
9
10
11
12
13
class Foo {
static bar() {
this.baz();
}
static baz() {
console.log('hello');
}
baz() {
console.log('world');
}
}

Foo.bar() // hello

从这个例子还可以看出,静态方法可以与非静态方法重名。特别注意的是:

1
2
3
4
5
6
7
8
9
10
class Foo {
static bar() {
this.baz();
}
baz() {
console.log('world');
}
}

Foo.bar() // TypeError: this.baz is not a function

因为this指向类本身,所以this.baz()表示用类调用方法,而只有静态方法才能被类调用,但是上述baz不是静态方法,所以报错

静态属性

在第一节中我们已经展示了如何在类中定义所有实例的共享属性。但如果在一个属性前,加上static关键字,就表示该属性不会被实例继承,而是直接通过类来调用,这就称为“静态属性”

1
2
3
4
5
6
class Foo {
static type = 123;
}
let foo = new Foo();
console.log(foo.type);//undefined
console.log(Foo.type);//123

静态属性也可以被子类继承

1
2
3
4
5
6
7
class A {
static type = 123;
}
class B extends A{

}
console.log(B.type);//123

私有属性与方法

#开头的属性名/方法名表示私有属性/方法。私有属性和方法不能被子类继承、不能由实例使用、不能在类的外部使用、只能在类的内部通过this.#......的形式调用(这儿的this指向的是实例,具体见下面“一些注意点——6”)

1
2
3
4
5
6
7
8
9
10
11
12
class Foo {
#type = 123;
#test(){
console.log(this.#type);
}
print(){
this.#test();
}
}
let foo = new Foo();
foo.print();//123
// console.log(foo.#type);//SyntaxError: Private field '#type' must be declared in an enclosing class

私有属性和私有方法前面,也可以加上static关键字,表示这是一个静态的私有属性或私有方法

浏览器对#支持不太好

一些注意点

1、类的内部所有定义的方法,都是不可枚举的(non-enumerable)

2、类必须使用new调用,否则会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行

3、与 ES5 一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyClass {
constructor() {
// ...
}
get prop() {
return 'getter';
}
set prop(value) {
console.log('setter: '+value);
}
}

let inst = new MyClass();

inst.prop = 123;
// setter: 123

inst.prop
// 'getter'

4、类和模块的内部,默认就是严格模式,所以不需要使用use strict指定运行模式

5、类不存在变量提升(hoist),这一点与 ES5 完全不同

1
2
new Foo(); // ReferenceError
class Foo {}

上面代码中,Foo类使用在前,定义在后,这样会报错,因为 ES6 不会把类的声明提升到代码头部

6、静态方法中的this指向类本身,而非静态方法中的this指向实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Foo {
#type = 123;
static fun(){
console.log(this === Foo);//true
}
#test(){
console.log(this === foo);//true
console.log(this.#type);
}
print(){
console.log(this === foo);//true
this.#test();
}
}
let foo = new Foo();
foo.print();//123
Foo.fun();

但是,必须非常小心,一旦单独使用该方法,很可能报错

1
2
3
4
5
6
7
8
9
10
11
12
13
class Logger {
printName(name = 'there') {
this.print(`Hello ${name}`);
}

print(text) {
console.log(text);
}
}

const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

上面代码中,printName方法中的this,默认指向Logger类的实例。但是,如果将这个方法提取出来单独使用,this会指向该方法运行时所在的环境(由于 class 内部是严格模式,所以 this 实际指向的是undefined),从而导致找不到print方法而报错

至此,类的基本知识已经讨论完毕。下面我们讨论类的继承相关内容

类的继承

继承语法

我们通过语法class Child extends Father表示继承。继承实现的原理是:将子类的原型中的原型设置为父类的原型

1
2
3
4
class A{}
class B extends A{}

console.log(B.prototype.__proto__ === A.prototype);//true

上述代码的继承原理如下图所示:

p1

可见,如果B的实例要找某个方法或属性便会沿着他的原型链一直找到父类的原型,从而实现了继承。这跟ES5中实现继承的方法是一致的。只不过ES6将整个过程封装为了语法糖

继承中的构造函数(constructor)及构造函数中的super

根据规范,如果子类没有 constructor,那么将生成下面这样的包含super(...)constructor

1
2
3
4
5
class B extends A {
constructor(...args) {
super(...args);
}
}

子类构造函数中的super(...)表示调用父类的constructor方法(你可以简单的将其理解为super(...) <==> A.prototype.constructor.call(this),但是这样理解并不准确)。在子类的构造函数中必须先使用super(...)调用父类的构造函数,然后才能使用this,否则创建子类的实例时将报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A{}
class B extends A{
constructor(name){
this.name = name;//ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
}
}
let b = new B();

/******/

class A{}
class B extends A{
constructor(name){
super();
this.name = name;
}
}
let b = new B('德洛丽丝');
console.log(b.name);//德洛丽丝

作为函数调用使用的super(...)只能用在子类的构造函数之中,用在其他地方就会报错

如果我们在子类中也显示定义了子类的constructor,那么这就叫做重写constructor(即重写父类的constructor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
}

class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
}

let rabbit = new Rabbit("White Rabbit", 10);
console.log(rabbit.name); // White Rabbit
console.log(rabbit.earLength); // 10
console.log(rabbit.speed); // 0

重写方法

所谓重写方法是指:按照原型链的搜索过程,子父类中有同名方法时,会执行子类中的方法(而不是父类中的方法)。因此,我们可在子类中覆盖父类中的同名方法,这就叫重写方法

我们除了能在子类中添加子类自己的方法,还能在子类中重写父类中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}

run(speed) {
this.speed = speed;
console.log(`${this.name} runs with speed ${this.speed}.`);
}

stop() {
this.speed = 0;
console.log(`${this.name} stands still.`);
}

}

class Rabbit extends Animal {
hide() {
console.log(`${this.name} hides!`);
}

stop() {
console.log('我是子类的stop');
this.hide();
}
}

let rabbit = new Rabbit("White Rabbit");

rabbit.run(5); // White Rabbit runs with speed 5.

rabbit.stop();
// 我是子类的stop
// White Rabbit hides!

上述代码中,子类重写了父类中的stop方法

重写属性

除了重写方法,子类还能重写父类的同名属性

但是,在重写属性时会有一个诡异的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Animal {
name = 'animal';

constructor() {
alert(this.name); // (*)
}
}

class Rabbit extends Animal {
name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal

/******/

class Animal {
showName() { // 而不是 this.name = 'animal'
alert('animal');
}

constructor() {
this.showName(); // 而不是 alert(this.name);
}
}

class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}

new Animal(); // animal
new Rabbit(); // rabbit

在上述第一块代码中的*处,我们理想应该输出rabbit,但是输出的却是父类中的name。但是当使用同名方法时(第二块代码),却能如我们所愿!为什么呢?

实际上,原因在于属性初始化的顺序。类属性是这样初始化的:

  • 对于基类(还未继承任何东西的那种),在构造函数调用前初始化。
  • 对于派生类,在 super() 后立刻初始化

所以,new Rabbit() 调用了 super(),因此它执行了父类构造器,并且(根据派生类规则)只有在此之后,它的类属性才被初始化。在父类构造器被执行的时候,Rabbit 还没有自己的类属性,这就是为什么 Animal 类属性被使用了(会搜索原型链找到父类的name属性)

因此,对于子类而言:在子类的构造函数中的super(...)执行完毕前使用子类中的属性是不可能的

super与this

接下来我们讨论super的相关内容。虽然前面已经有所涉及,但还有很多super的特性并未提及,在此节我们将对此做一个系统梳理

super关键字有三种用法:

  • 通过super.方法名(...)来调用一个父类方法
  • 在子类的构造函数中通过super(...)调用父类的constructor
  • 在普通{}对象的方法中也可以使用super关键字

上述前面两种都与类相关,最后一种与普通对象相关。下面我们分为两类来讨论super

super在普通对象的方法中的使用

此时,super指向它所在对象的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
const proto = {
foo: 'hello'
};

const obj = {
foo: 'world',
find() {
return super.foo;
}
};

Object.setPrototypeOf(obj, proto);
obj.find() // "hello"

如果使用super调用它所在对象的原型中的方法,那么原型方法中的this指向的是super所在对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const proto = {
x: 'hello',
foo() {
console.log(this.x);
},
};

const obj = {
x: 'world',
foo() {
super.foo();
}
}

Object.setPrototypeOf(obj, proto);

obj.foo() // "world" 不是hello

需要特别注意的是:super只能用在对象方法的简写形式中(即不是方法名:function(){...}形式定义的方法中,详见“对象的扩展”一章)

super在类中的使用

  • super作为对象时,在普通方法中,指向父类的原型对象(即class 类名{...}中的{...});在静态方法中,指向父类(即类名所代表的函数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A{
static test1(){
console.log('A的静态方法test1');
}
test1(){
console.log('A的公共方法test1');
}
}

class B extends A{
static test1(){
super.test1();
}
test2(){
super.test1();
}
}

let b = new B();
b.test2();//A的公共方法test1
B.test1();//A的静态方法test1
  • ES6 规定,在子类普通方法中通过super调用父类的方法时,父类方法内部的this指向当前的子类实例。在子类的静态方法中通过super调用父类的方法时,父类方法内部的this指向当前的子类,而不是子类的实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class A {
constructor() {
this.x = 1;
}
print() {
console.log(this.x);
}
}

class B extends A {
constructor() {
super();
this.x = 2;
}
m() {
super.print();
}
}

let b = new B();
b.m() // 2

/******/

class A {
constructor() {
this.x = 1;
}
static print() {
console.log(this.x);
}
}

class B extends A {
constructor() {
super();
this.x = 2;
}
static m() {
super.print();
}
}

B.x = 3;
B.m() // 3

注意,使用super的时候,必须显式指定是作为函数(即super(...)的形式使用super)、还是作为对象使用(即super.属性/方法的形式使用super),否则会报错

1
2
3
4
5
6
7
8
class A {}

class B extends A {
constructor() {
super();
console.log(super); // 报错
}
}

(11) Promise

Promise的基本概念

为什么会有Promise

Promise是异步编程的一种解决方案,主要是为了解决异步编程时的回调地狱问题。通过Promise,便可以将回调地狱变为线性的操作(具体看下面的案例)

ES6将Promise写入了语言规范,并提供了Promise对象

Promise实例的状态

Promise实例有三种状态:

  • pending:进行中
  • fulfilled:成功。有时我们也将fulfilled称为resolved(本文后面都将采用这种称呼)
  • rejected:失败

Promise实例的状态改变只有两种可能:

  • pending变为resolved(异步操作成功时的状态变化)
  • pending变为rejected(异步操作失败时的状态变化)

一旦状态变为resolved/rejected,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 settled(已定型)。如果一个Promise实例的状态已定型,那么你再为实例注册状态改变时的回调函数,那么与实例当前状态所对应的回调函数会立即执行(加粗部分对理解后面链式调用时的执行流程十分重要)

Promise的用法

在使用Promise时,首先要通过Promise构造函数创建一个Promise实例:

1
const promise = new Promise(function(resolve, reject) { //异步代码(比如网络请求等) });

Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolvereject(参数名是任意的,这儿只是为了专业点)。它们是两个函数,由 JavaScript 引擎提供,不用自己部署,使用时直接调用就行(如resolve(...))

resolve(reject)函数的作用有二:一是,将Promise实例的状态从pending变为resolved(rejected);二是,这两个函数都接受一个参数,通过参数将异步任务的结果传给在then中注册的状态改变时的回调函数(见下面的then方法)

除了调用resolve/reject会改变Promise实例的状态以外。前面//异步代码(比如网络请求等)中如果抛出了错误,那么Promise状态会变为rejected。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let promise = new Promise((resolve, reject) => {
resolve(123);
});
promise.then((value) => {
return new Promise((resolve, reject) => {
throw new Error('test');//手动抛错
});
}, (reason) => {
console.log(reason + '--');
}).then(value => {
console.log(value);
}, reason => {
console.log(reason + '++');//Error: test++
});

/********************/

let promise = new Promise((resolve, reject) => {
let y = x + 1;//x未定义,抛出错误
});
promise.then((value) => {
console.log('ok' + value);
}, (reason) => {
console.log(reason + '--');//ReferenceError: x is not defined--
});

上面阐述了如何创建Promise实例,下面讲述Promise实例的方法:

所有Promise实例方法的返回值都是Promise实例,因此可以链式调用这些实例方法。同时要重点注意各个实例方法所返回的Promise实例的状态(MDN中有详细说明),这样才能理解好链式调用的执行流程

1、then(resolveCallback, rejectCallback)

then方法的作用是为 Promise 实例添加状态改变时的回调函数。then方法的第一个参数是resolved状态的回调函数,第二个参数是rejected状态的回调函数,它们都是可选的。两个回调函数都接受一个参数,该参数就是创建Promise实例时,resolve/reject(...)传过来的参数

then方法返回的Promise实例的状态有六种情况,具体参见**MDN-Return value**(非常重要)

重点注意:当我们在then的回调函数中手动返回一个Promise对象时,then返回的并不是我们手动返回的那个Promise对象,而依然是then自己的Promise对象,只不过此时then返回的Promise对象的状态由我们手动返回的Promise对象的状态决定(可以看上面链接中的最后一个情况,里面有说明)。实际上,then永远返回的都是自己的Promise对象(即使我们手动返回了Promise对象),不仅仅then,后面的实例方法都是如此

下面我们演示通过Promise解决回调地狱的例子(不用在意例子中使用的还未学习的技术,注重看Promise):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 例子描述:
// 使用Nodejs的fs模块异步读取md文件中的字符串内容,然后打印
const fs = require('fs');
const { rejects } = require('node:assert');
const { resolve } = require('node:path');

// 回调地狱写法
fs.readFile('./resources/为学.md', (err, data1) => {
fs.readFile('./resources/插秧诗.md', (err, data2) => {
fs.readFile('./resources/观书有感.md', (err, data3) => {
let result = data1 + data2 + data3;
console.log(result);
});
});
});

// Promise写法
const p = new Promise((resolve, reject) => {
fs.readFile('./resources/为学.md', (err, data) => {
resolve(data);//读取成功后将结果传给then中注册的resolveCallback回调函数
});
});

p.then((value) => {
return new Promise((resolve, reject) => {
fs.readFile('./resources/插秧诗.md', (err, data) => {
resolve([data, value]);//读取成功后将结果传给then中注册的resolveCallback回调函数
});
});
}).then((value) => {
return new Promise((resolve, reject) => {
fs.readFile('./resources/观书有感.md', (err, data) => {
resolve(value.push(data));//读取成功后将结果传给then中注册的resolveCallback回调函数
});
});
}).then((value) => {
console.log(value[0] + value[1] + value[2]);
});

上例中只在then中注册了resolved状态的回调,没有注册rejected状态的回调。可见,将回调地狱变为了类似于同步编程的线性操作

2、catch(rejectCallback)

catch方法的作用也是为 Promise 实例添加状态改变时的回调函数,但是catch只处理rejected状态的Promise实例。而且官方建议使用catch来代替then中的rejectCallback回调函数,使then专门处理resolved状态的Promise实例,这样更加规范

catch返回的Promise实例的状态见**Parameters**部分的最后一句话

1
2
3
4
5
6
7
8
9
let promise = new Promise((resolve, reject) => {//promise为第1个Promise实例
reject('失败');
});

promise.then(value => {//then返回第2个Promise实例
console.log('成功');
}).catch(reason => {
console.log(reason);//失败
})

这儿你可能十分费解上述链式调用的执行流程。下面我便为例详细阐述:

then实例方法还有一个非常重要的特性就是:假如有Promise实例.then(...),那么如果Promise实例处于rejected/resolved状态,但是then方法没有处理上述状态的状态改变回调函数,那么此时then返回的Promise实例与Promise实例的状态一致

第1个Promise实例为rejected状态,但是then没有处理该状态的rejectCallback状态改变回调函数,所以then返回的第2个Promise实例依然为rejected状态,然后catch处理了该状态(此处catch还会返回一个resolved状态的Promise实例,只是后面没有链式调用处理了)

如果Promise实例不是rejected状态,则会跳过catch

1
2
3
4
5
6
7
8
9
let promise = new Promise((resolve, reject) => {//promise为第1个Promise实例
resolve('成功');
});

promise.catch(reason => {
console.log(reason);
}).then(value => {
console.log(value);//成功
});

3、finally(onFinally)

finally()方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。onFinally是一个函数,该函数不接受任何参数

1
2
3
4
promise
.then(result => {···})
.catch(error => {···})
.finally(() => {···});

上面代码中,不管promise最后的状态,在执行完thencatch指定的回调函数以后,都会执行finally方法指定的回调函数

至此,Promise实例的方法介绍完毕。为了更深刻地理解链式调用的执行流程,我们举一个综合性的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let promise = new Promise((resolve, reject) => {//promise为第1个Promise实例
reject('失败');
});

promise.then((value) => {//then返回第2个Promise实例
console.log('ok' + value);
}, (reason) => {
console.log(reason + '--');
}).then(value => {//then返回第3个Promise实例
return new Promise((resolve, reject) => {
reject('再次失败');
})
}).catch((reason) => {//catch返回第4个Promise实例
console.log('no1' + reason);
}).then(value => {//then返回第5个Promise实例,但后面没有链式调用进行处理了
console.log('HK416');
}).finally(() => {
console.log('结束了');
});
// 失败--
// no1再次失败
// HK416
// 结束了

第1个Promise实例为rejected状态,故执行后面then中的rejectCallback回调;第2个Promise实例为resolved状态(见前面“then方法返回的Promise实例的状态有六种情况”),故执行后面then中的resolveCallback回调;第3个Promise实例为rejected状态,故执行后面catch中的rejectCallback回调;第4个Promise实例为resolved状态,故执行后面then中的resolveCallback回调;第5个Promise实例为resolved状态,无论如何都会执行onFinally

所以,判断链式调用的执行流程很简单——基于每次返回的Peomise实例的状态判断就行

其他内容

1、如何中断Promise链?

在实战时遇见了这样一种情况:在链式处理Promsie的过程中,在链的某个位置已经得出结果后便不需要再执行后续的链式处理了。此时我们就需要用到中断Promise

中断方法是:当返回一个pending状态的Promise时,便会中断Promise链的执行

1
2
3
4
5
6
7
8
9
10
11
Promise.resolve()
.then(() => {
console.log('[onFulfilled_1]');
return new Promise(()=>{}); // 返回“pending”状态的Promise对象
})
.then(() => { // 后续的函数不会被调用
console.log('[onFulfilled_2]');
})
.catch(err => {
console.log('[catch]', err);
});

(10) Proxy(代理)与Reflect(反射)

Proxy

什么是Proxy

Proxy主要用于对象,用于修改对象操作(如读取/添加/删除对象属性)的默认行为。Proxy 可以理解成,在被代理对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写

Proxy属于“元编程”,即对编程语言进行编程。说得再通俗易懂写就是——修改语言原始定义的默认行为,以按照自己的意愿进行个性化设置

1
2
3
4
5
6
7
8
9
var proxy = new Proxy({}, {
get: function(target, propKey) {
return 35;
}
});

proxy.time // 35
proxy.name // 35
proxy.title // 35

上述代码中,为一个空对象设置了代理,并在代理中更改了获取属性操作(即.)的行为,所以后面获取属性值的操作都会被该代理拦截,从而按照你的设置返回35

Proxy的使用

1、首先要创建代理对象

ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例

var proxy = new Proxy(target, handler);

  • target为被代理的对象
  • handler也是一个对象,用来定制拦截行为

下面是Proxy支持的可以定制的拦截行为:

  1. get(target, propKey, receiver):拦截对象属性的读取,比如proxy.fooproxy['foo']
  2. set(target, propKey, value, receiver):拦截对象属性的设置,比如proxy.foo = vproxy['foo'] = v
  3. 其他行为可查阅资料,不再一一赘述

get/set方法的参数target, propKey, receiver分别表示被代理的对象, 你所读取的属性名, 代理实例本身setvalue参数表示你设置的属性值

2、使用 Proxy 实例操作对象

Proxy 实例创建完毕后,应用Proxy实例操作对象,而不能还是由原对象操作自身,否则你定制的拦截行为不会生效

下面给出一个完整的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let obj = {
name : '黑尔'
};
let proxy = new Proxy(obj, {
get(target, propKey, receiver){
console.log('正在获取属性值...');
return target[propKey];
},
set(target, propKey, value, receiver){
console.log(`${propKey}的值更新了...`);
target[propKey] = value;
}
});
proxy.name = '伯纳德';
//name的值更新了...

console.log(proxy.name);
// 正在获取属性值...
// 伯纳德

在使用Proxy时需要注意:

  1. Proxy的兼容性不好,有的环境并没实现。由于它不属于语法糖,而是实实在在的新增内容,所以不能通过babel转换为ES5
  2. 严格模式下,set代理必须返回true,否则报错
  3. 如果一个属性不可配置(configurable)且不可写(writable),则 Proxy 不能修改该属性,否则通过 Proxy 对象访问该属性会报错

可取消的 Proxy 实例

ES6还提供了Proxy.revocable()方法,以返回一个可取消的 Proxy 实例

1
2
3
4
5
6
7
8
9
10
let target = {};
let handler = {};

let {proxy, revoke} = Proxy.revocable(target, handler);

proxy.foo = 123;
proxy.foo // 123

revoke();
proxy.foo // TypeError: Revoked

Proxy.revocable()方法返回一个对象,该对象的proxy属性是Proxy实例,revoke属性是一个函数,可以取消Proxy实例。上面代码中,当执行revoke函数之后,再访问Proxy实例,就会抛出一个错误

this指向

1、在 Proxy 代理的情况下,通过Proxy实例访问被代理对象的方法时,方法内部的this关键字会指向 Proxy 代理实例

1
2
3
4
5
6
7
8
9
10
11
const target = {
m: function () {
console.log(this === proxy);
}
};
const handler = {};

const proxy = new Proxy(target, handler);

target.m() // false
proxy.m() // true

2、Proxy 拦截函数内部的this,指向的是handler对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const handler = {
get: function (target, key, receiver) {
console.log(this === handler);
return 'Hello, ' + key;
},
set: function (target, key, value) {
console.log(this === handler);
target[key] = value;
return true;
}
};

const proxy = new Proxy({}, handler);

proxy.foo
// true
// Hello, foo

proxy.foo = 1
// true

Reflect

Reflect对象的设计目的有这样几个

  • Reflect对象所提供的大部分方法与Object中的同名方法作用相同。因此你可以将Reflect中的方法等同于Object中的方法,以后便可以用Reflect中的方法来替换Object中的同名方法(这也是规范所期望的)。同时,Reflect中的方法规范了Object中同名方法的行为
  • Proxy配合使用。由于Reflect中的方法的行为就是语言所定义的默认行为,所以如果Proxy的拦截函数中需要用默认行为操作对象时,便可使用Reflect中的方法

对于Reflect对象的设计目的更为详细的叙述建议参见**Reflect概述**

我们对上面“2、使用 Proxy 实例操作对象”中的代码用Reflect进行优化,这样更合理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let obj = {
name : '黑尔'
};
let proxy = new Proxy(obj, {
get(target, propKey, receiver){
console.log('正在获取属性值...');
return Reflect.get(target, propKey, receiver);//使用Reflect执行对对象的默认操作
},
set(target, propKey, value, receiver){
console.log(`${propKey}的值更新了...`);
Reflect.set(target, propKey, value, receiver);//使用Reflect执行对对象的默认操作
}
});
proxy.name = '伯纳德';
//name的值更新了...

console.log(proxy.name);
// 正在获取属性值...
// 伯纳德

对Reflect中的方法不在此作一一阐述,使用时可查阅**Reflect静态方法**